Skip to content

fix: SCIM compatibility, user deletion FK constraints, and agent update UX#57

Merged
TerrifiedBug merged 6 commits intomainfrom
fix/scim-and-review-fixes
Mar 7, 2026
Merged

fix: SCIM compatibility, user deletion FK constraints, and agent update UX#57
TerrifiedBug merged 6 commits intomainfrom
fix/scim-and-review-fixes

Conversation

@TerrifiedBug
Copy link
Copy Markdown
Owner

Summary

Follow-up fixes from testing the operations UX improvements (#51) against pocket-id SCIM sync.

  • SCIM user adoption: POST /Users now adopts existing users (e.g. created via OIDC login) by linking their scimExternalId instead of returning 409. Returns 200 for adopted users, 201 for new.
  • SCIM Groups POST/DELETE: Added POST /api/scim/v2/Groups to create teams via SCIM (adopts existing by name). Added DELETE /api/scim/v2/Groups/[id] to remove team memberships. Fixes 405 errors from pocket-id.
  • User deletion FK constraints: Fixed foreign key constraints that blocked deleting SSO users:
    • TeamMember.userIdonDelete: Cascade
    • VrlSnippet.createdByonDelete: SetNull (nullable)
    • DeployRequest.requestedByIdonDelete: SetNull (nullable)
    • ServiceAccount.createdByIdonDelete: SetNull (nullable)
  • Agent update UX: Force-refresh dev release data before triggering binary agent update to avoid stale download URLs.

Test plan

  • SCIM: Trigger sync from pocket-id — users should be adopted (no 409 errors)
  • SCIM: Groups should sync without 405 errors
  • SCIM: Verify group membership sync creates team memberships
  • Admin: Delete an SSO user — should succeed without FK constraint errors
  • Fleet: Trigger binary agent update — should fetch fresh release data

Rolling dev releases replace the binary at the download URL on every
push to main. When the UI caches version/checksum data and a new build
lands before the user clicks Update, the agent downloads the new binary
but the checksum from the stale cache doesn't match — causing a silent
update failure loop.

Force-refresh dev release info server-side in triggerAgentUpdate so the
checksum always matches the binary currently at the download URL.
…LETE, fix user FK constraints

- SCIM user creation now adopts existing users by linking scimExternalId
  instead of returning 409 (fixes pocket-id sync for pre-existing OIDC users)
- Added POST /api/scim/v2/Groups to create teams via SCIM
- Added DELETE /api/scim/v2/Groups/[id] to remove team memberships via SCIM
- Fixed foreign key constraints that blocked user deletion:
  TeamMember.userId → onDelete: Cascade
  VrlSnippet.createdBy → onDelete: SetNull (nullable)
  DeployRequest.requestedById → onDelete: SetNull (nullable)
  ServiceAccount.createdById → onDelete: SetNull (nullable)
@github-actions github-actions bot added the fix label Mar 7, 2026
downloadUrl is never reassigned — only parsed for the binary name.
Split from let destructuring to satisfy prefer-const lint rule.
@TerrifiedBug
Copy link
Copy Markdown
Owner Author

@greptile review

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This PR delivers follow-up fixes for SCIM compatibility (pocket-id sync), user deletion FK constraints, and agent update UX. The SCIM changes (group POST/DELETE, user adoption) are well-structured, the schema/migration changes are consistent and correct, and the fleet force-refresh correctly throws on failure rather than silently proceeding with stale data.

Key changes:

  • POST /api/scim/v2/Groups and DELETE /api/scim/v2/Groups/[id] added to fix 405 errors from pocket-id; group DELETE intentionally preserves the team record and only clears memberships
  • scimCreateUser now adopts existing OIDC/SCIM-linked users (returns { user, adopted }) and guards local-credential accounts behind a 409 — correctly enforcing the SSO-linking policy
  • Four FK constraints fixed (TeamMember CASCADE, VrlSnippet/DeployRequest/ServiceAccount SET NULL + nullable columns) with matching migration
  • fleet.ts force-refreshes dev release checksums and throws explicitly when GitHub is unreachable or checksum is missing — closes the stale-checksum regression
  • Issue found: In deploy.ts, approving a pending request from a deleted user falls back to ctx.session.user.id as the userId passed to deployAgent. Because deployAgent passes that value straight to createVersion and the git-sync commit, the approving admin is permanently recorded as the pipeline version's creator — not just its reviewer. The DeployRequest.reviewedById is correct, but the PipelineVersion authorship and git history are wrong.
  • Minor: The scim.group_deleted audit action fires even though the team record is preserved (only memberships are cleared), which can mislead admins reading the audit trail.

Confidence Score: 3/5

  • Safe to merge with awareness of the deploy-request attribution issue, which will produce incorrect PipelineVersion authorship for approvals of deleted-user requests.
  • The SCIM, schema, migration, and fleet changes are all correct and well-guarded. The one meaningful correctness bug is in deploy.ts: the requestedById ?? ctx.session.user.id fallback permanently stamps the approving admin as the PipelineVersion creator in an immutable record, corrupting audit trails in the specific scenario where a user with a pending deploy request is deleted. This will manifest under normal usage (user deletion is now explicitly supported) and affects an immutable record, making it hard to correct after the fact.
  • src/server/routers/deploy.ts — the userId fallback on line 370 needs attention before merge if accurate PipelineVersion authorship matters.

Important Files Changed

Filename Overview
src/server/routers/deploy.ts Fallback requestedById ?? ctx.session.user.id permanently misattributes the approving admin as the PipelineVersion creator and git-sync commit author when the original requester has been deleted.
src/server/routers/fleet.ts Force-refreshes dev release data before agent update, throws on GitHub unreachability or missing checksum — correctly prevents stale checksum delivery.
src/server/services/scim.ts Adopts existing OIDC/SCIM users on POST instead of 409; guards local-credential accounts behind a 409 conflict — addresses the security policy requirement for explicit admin linking.
src/app/api/scim/v2/Groups/route.ts New POST handler creates or adopts teams by display name; includes audit logs for both paths; correct 200/201 status differentiation.
src/app/api/scim/v2/Groups/[id]/route.ts New DELETE handler clears team memberships rather than deleting the team; audit action name scim.group_deleted is misleading since the team record is preserved.
prisma/schema.prisma Makes VrlSnippet.createdBy, DeployRequest.requestedById, and ServiceAccount.createdById nullable with SET NULL on delete; cascades TeamMember.userId — aligns with migration SQL.

Sequence Diagram

sequenceDiagram
    participant IdP as pocket-id (SCIM)
    participant API as /api/scim/v2
    participant SCIM as scim.ts
    participant DB as PostgreSQL

    Note over IdP,DB: User Provisioning
    IdP->>API: POST /Users {email, externalId}
    API->>SCIM: scimCreateUser(body)
    SCIM->>DB: findUnique({email})
    alt User exists (OIDC/SCIM-linked)
        DB-->>SCIM: existing user
        SCIM->>DB: update scimExternalId
        SCIM-->>API: {user, adopted: true}
        API-->>IdP: 200 OK
    else User exists (local credential)
        DB-->>SCIM: existing user (passwordHash set)
        SCIM-->>API: throw {scimConflict: true}
        API-->>IdP: 409 Conflict
    else User not found
        SCIM->>DB: create user (random password)
        SCIM-->>API: {user, adopted: false}
        API-->>IdP: 201 Created
    end

    Note over IdP,DB: Group Sync
    IdP->>API: POST /Groups {displayName}
    API->>DB: findFirst({name})
    alt Team exists
        DB-->>API: existing team
        API-->>IdP: 200 OK (adopted)
    else Team not found
        API->>DB: create team
        API-->>IdP: 201 Created
    end

    IdP->>API: DELETE /Groups/{id}
    API->>DB: deleteMany TeamMember (teamId)
    Note right of DB: Team record preserved
    API-->>IdP: 204 No Content
Loading

Last reviewed commit: 9da431d

- Gate SCIM user adoption to SSO accounts only (authMethod OIDC or
  existing scimExternalId). Local-credential accounts now reject with
  a clear error instead of silently linking to IdP identity.
- Add missing audit log for SCIM group adoption (POST /Groups returning
  200 for existing team).
- Always apply fresh checksum for dev releases regardless of version
  string change — the binary can be rebuilt at the same commit, so
  the version comparison guard was insufficient.
@TerrifiedBug
Copy link
Copy Markdown
Owner Author

TerrifiedBug commented Mar 7, 2026

@greptile review

Fixed.

All three findings.

- Dev release updates now throw when checkDevAgentVersion returns no
  version (GitHub unreachable), instead of silently falling through
  with the stale UI-cached checksum.
- SCIM local-account conflicts return 409 per RFC 7644 §3.3 instead
  of 400, signaling to the SCIM client that the conflict is permanent.
@TerrifiedBug
Copy link
Copy Markdown
Owner Author

@greptile review. fixed.

GitHub unavailability silent │ Valid │ Throw INTERNAL_SERVER_ERROR when latestVersion │
│ fallback │ │ is null │
├──────────────────────────────┼─────────┼────────────────────────────────────────────────┤
│ SCIM conflict returns 400 │ Valid │ Tagged error with scimConflict, route returns │
│ not 409 │ │ 409 per RFC 7644

const result = await deployAgent(
request.pipelineId,
request.requestedById,
request.requestedById ?? ctx.session.user.id,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Admin misattributed as requester in PipelineVersion

When requestedById is null (the original requester was deleted), the fallback ctx.session.user.id is passed as the userId to deployAgent. Inside deployAgent, this value is used in two places:

  1. createVersion(pipelineId, ..., userId, ...) — the immutable PipelineVersion snapshot is permanently stamped with the approving admin's ID as the version creator.
  2. prisma.user.findUnique({ where: { id: userId } }) — the git sync commit is attributed to the admin.

So the admin who clicks "Approve" now permanently appears as the person who both requested and deployed the pipeline version — not just as the reviewer. The DeployRequest row correctly records reviewedById = ctx.session.user.id, but the PipelineVersion is a separate record that future audits and git history will show as authored by the admin.

Consider passing null (or a sentinel like "deleted-user") and updating createVersion/deployAgent to accept string | null for the userId, then treating null as "original requester deleted":

// Option: pass null and let deployAgent handle it
const result = await deployAgent(
  request.pipelineId,
  request.requestedById,   // may be null — deployAgent should handle
  request.changelog,
  request.configYaml,
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/server/routers/deploy.ts
Line: 370

Comment:
**Admin misattributed as requester in PipelineVersion**

When `requestedById` is `null` (the original requester was deleted), the fallback `ctx.session.user.id` is passed as the `userId` to `deployAgent`. Inside `deployAgent`, this value is used in two places:

1. `createVersion(pipelineId, ..., userId, ...)` — the immutable `PipelineVersion` snapshot is permanently stamped with the approving admin's ID as the version creator.
2. `prisma.user.findUnique({ where: { id: userId } })` — the git sync commit is attributed to the admin.

So the admin who clicks "Approve" now permanently appears as the person who both requested and deployed the pipeline version — not just as the reviewer. The `DeployRequest` row correctly records `reviewedById = ctx.session.user.id`, but the `PipelineVersion` is a separate record that future audits and git history will show as authored by the admin.

Consider passing `null` (or a sentinel like `"deleted-user"`) and updating `createVersion`/`deployAgent` to accept `string | null` for the userId, then treating null as "original requester deleted":

```typescript
// Option: pass null and let deployAgent handle it
const result = await deployAgent(
  request.pipelineId,
  request.requestedById,   // may be null — deployAgent should handle
  request.changelog,
  request.configYaml,
);
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +285 to +291

await writeAuditLog({
userId: null,
action: "scim.group_deleted",
entityType: "Team",
entityId: id,
metadata: { displayName: team.name },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Audit action scim.group_deleted is misleading

The handler intentionally preserves the team (to avoid cascading deletes of environments, pipelines, etc.) — it only removes memberships. But the audit entry fires "scim.group_deleted", which would lead an admin reviewing the audit trail to believe the team was removed when it wasn't.

Consider using an action name that accurately describes what occurred:

Suggested change
await writeAuditLog({
userId: null,
action: "scim.group_deleted",
entityType: "Team",
entityId: id,
metadata: { displayName: team.name },
await writeAuditLog({
userId: null,
action: "scim.group_memberships_cleared",
entityType: "Team",
entityId: id,
metadata: { displayName: team.name },
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/api/scim/v2/Groups/[id]/route.ts
Line: 285-291

Comment:
**Audit action `scim.group_deleted` is misleading**

The handler intentionally preserves the team (to avoid cascading deletes of environments, pipelines, etc.) — it only removes memberships. But the audit entry fires `"scim.group_deleted"`, which would lead an admin reviewing the audit trail to believe the team was removed when it wasn't.

Consider using an action name that accurately describes what occurred:

```suggestion
  await writeAuditLog({
    userId: null,
    action: "scim.group_memberships_cleared",
    entityType: "Team",
    entityId: id,
    metadata: { displayName: team.name },
  });
```

How can I resolve this? If you propose a fix, please make it concise.

@TerrifiedBug TerrifiedBug merged commit 99ca7b8 into main Mar 7, 2026
9 checks passed
@TerrifiedBug TerrifiedBug deleted the fix/scim-and-review-fixes branch March 7, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant